# FluxValidator - AJAX Form Validation for Rails
# Copyright (c) 2007 Peter Gumeson
# 
# FluxValidator is freely distributable under the terms of an MIT license
module FluxValidator
  
  def self.included(base)
    base.extend ClassMethods
    base.class_inheritable_accessor(:flux_validator_configuration)
    base.flux_validator_configuration = ::FluxValidator::Configuration.new
  end
  
  module ClassMethods
    # Allows FluxValidator to validate fields that
    # are not named the same as the model class.
    #
    # alias_model <field_name>, <model_class>
    #
    # Usage Examples:
    # alias_model :my_user, :user
    # alias_model 'my_user', User
    # alias_model :my_user, 'User'
    #
    # Now the 'my_user' fields below will be
    # validated with the User model.
    #
    # <%= text_field :my_user, :login %>
    # <%= text_field 'my_user', :email %>
    # <input type="text" name="my_user[phone_number]" />
    #
    def alias_model(name, model)
      raise ArgumentError, "name cannot be nil for alias_model" if name.nil?
      raise ArgumentError, "model cannot be nil for alias_model" if model.nil?
      name = name.to_s.underscore.to_sym
      klass = model.to_s.camelize.constantize rescue nil
      unless klass && klass.respond_to?('new') && klass.new.is_a?(ActiveRecord::Base)
        raise ArgumentError, "alias_model did not recognize #{model} as a valid model object"
      end
      flux_validator_configuration.aliases[name] = klass
    end
  end #ClassMethods
  
  # Stores the aliases for each controller
  class Configuration #:nodoc:
    attr_accessor :aliases
    def initialize
      @aliases = Hash.new
    end
  end
  
  # Creates and validates a hash of ActiveRecord objects
  # based on the names of incoming form params, or based
  # on the names found in the @mapping hash.
  #
  # Example:
  # <input type="text" name="user[email]" value="">
  # This would use the User model to create an instance variable
  # named @user and would then call @user.valid?()
  #
  # Example:
  # <input type="text" name="my_user[email]" value="">
  # If the MyUser class does not exist, you would need to map
  # my_user to its class name with something like this:
  # @mapping['my_user'] = User
  # Now it would use the User model to create an instance variable
  # named @my_user and would then call @my_user.valid?()
  #
  def validate_form
    @records = {}
    params.each_pair do |name, value|
      if value.is_a?(Hash)
        type = aliases.has_key?(name.to_sym) ? aliases[name.to_sym].to_s : name
        record = get_instance(name, type, params[name])
        unless record.nil?
          record.valid?
          @records[name] = record
        end
        # TODO: enable field with index functionality
        #value.each_pair do |iname, ivalue|
        #  if ivalue.is_a?(Hash)
        #    itype = aliases.has_key?(iname.to_sym) ? aliases[iname.to_sym].to_s : iname
        #    record = get_instance("#{name}_#{iname}", itype, params[name][iname])
        #    unless record.nil?
        #      record.valid?
        #      @records["#{name}_#{iname}"] = record
        #    end
        #  end
        #end
      end
    end
    
    render :update do |page|
      # Create a JavaScript hash of field error
      # messages keyed by the field name
      page << "var formErrors = new Hash();"
      @records.each_pair do |object, record|
        fields = []
        record.errors.each { |attr, msg| fields << attr }
        fields.uniq.each do |method|
          id = "#{object}_#{method}"
          html = inner_error_messages_on(object.to_sym, method.to_sym)
          page << "formErrors['#{id}'] = '#{escape_javascript(html)}';"
        end
      end
      
      # Loop through each error message and update
      # the field if any error message has changed
      javascript = <<-END_OF_SCRIPT
        formErrors.each(function(error, index) {
          var scope = error.key+'_scope';
          var fieldSpan = $(error.key+'_field');
          if (fieldSpan != null && fieldSpan.hasClassName('fieldWithoutErrors')) {
            fieldSpan.removeClassName('fieldWithoutErrors');
            fieldSpan.addClassName('fieldWithErrors');
          }
          var errorSpan = $(error.key+'_errors');
          var html = error.value;
          if (errorSpan != null && errorSpan.hasClassName('formErrors')) {
            if (errorSpan.innerHTML != '') {
              if (!errorSpan.equals(html)) {
                new Effect.SlideUp(errorSpan, {duration: 0.5, queue:{position:'front',scope:scope}, afterFinish:function(){ errorSpan.update(html); new Effect.SlideDown(errorSpan, {duration: 0.5, queue:{position:'end',scope:scope}}); }});
              }
              else if (errorSpan.id == FluxValidator.source+'_errors') {
                new Effect.Shake(errorSpan, 10, {queue:{position:'front',scope:scope}});
              }
            }
            else {
              errorSpan.update(html);
              new Effect.SlideDown(errorSpan, {duration: 0.5, queue:{position:'end',scope:scope}});
            }
          }
        });
      END_OF_SCRIPT
      page << javascript
            
      # Loop through each fieldWithErrors span and toggle any
      # 'fieldWithErrors' class to 'fieldWithoutErrors' if there
      # is no longer an error on the field
      javascript = <<-END_OF_SCRIPT
        var fieldSpans = FluxValidator.form.getElementsByClassName('fieldWithErrors');
        fieldSpans.each(function(span, index) {
          id = span.readAttribute('id');
          if (id != null) {
            key = id.sub('_field', '');
            if (formErrors.keys().indexOf(key) == -1) {
              span.removeClassName('fieldWithErrors');
              span.addClassName('fieldWithoutErrors');
            }
          }
        });
      END_OF_SCRIPT
      page << javascript
      
      # Loop through each formErrors span and hide it if there
      # is no longer an error on the field
      javascript = <<-END_OF_SCRIPT
        var errorSpans = $$('span.formErrors');
        errorSpans.each(function(span, index) {
          id = span.readAttribute('id');
          key = id.sub('_errors', '');
          if (formErrors.keys().indexOf(key) == -1 && span.innerHTML != '') {
            new Effect.SlideUp(span, {duration: 0.5, queue:'end', afterFinish:function(){ span.update('') }});
          }
        });
      END_OF_SCRIPT
      page << javascript
    end #render
  end #validate_form
  
  # Let controller respond to validate_(action_name)_form
  # action calls and route to validate_form instead
  def method_missing(method_name, *args)
    if (method_name.to_s.starts_with?('validate_') && method_name.to_s.ends_with?('_form'))
      send(validate_form, args)
    elsif (method_name.class != Symbol) # Allow super closure
      raise ActionController::UnknownAction, "No action responded to #{action_name}", caller
    end
  end
  
  private

  # Access the hash of aliased model names
  # from this controller's configuration 
  def aliases
    flux_validator_configuration.aliases
  end

  # Finds or creates an instance variable with name and type
  # and initialize it with supplied params hash
  def get_instance(name=nil, type=nil, params=nil)
    klass = type.camelize.to_class
    if klass.nil?
      message = "FluxValidator could not find a model named #{type.camelize} to validate form fields named '#{name}'. " +
          "Try adding an alias to your #{self.class} that defines a valid model class for '#{name}':\n\nalias_model :#{name}, <ValidModelClass>\n\n"
      RAILS_DEFAULT_LOGGER.error("\nERROR: #{message}")
      return nil
    end
    if obj = instance_variable_get("@#{name}")
      unless obj.instance_of?(klass)
        message = "FluxValidator found an instance variable named @#{name} but it was not a valid #{type.camelize} object. " +
            "Make sure your #{self.class}.validate_form() method assigns a #{type.camelize} model to @#{name} and not #{obj.class}."
        raise(TypeError, message)
      end
    else
      instance = klass.new
      add_mass_assign_attributes_method(instance)
      instance.mass_assign_attributes(params)
      instance_variable_set("@#{name}", instance)
    end
    
    if obj.nil?
      obj = instance_variable_get("@#{name}")
      obj.attributes = params
    end
    return obj
  end

  # Create a +mass_assign_attributes+ method on a single instance of
  # ActiveRecord that will allow the mass assignment of attributes even
  # if the model has +attr_protected+ or +attr_accessible+ defined.
  # This is safe because we never save this instance of the model.
  def add_mass_assign_attributes_method(obj)
    klass = class <<obj; self; end
    klass.send(:define_method, :mass_assign_attributes) do |new_attributes|
      return if new_attributes.nil?
      attributes = new_attributes.dup
      attributes.stringify_keys!
      multi_parameter_attributes = []
      attributes.reject{ |key, value| attributes_protected_by_default.include?(key.gsub(/\(.+/, "")) }.each do |k, v|
        k.include?("(") ? multi_parameter_attributes << [ k, v ] : send(k + "=", v)
      end
      assign_multiparameter_attributes(multi_parameter_attributes)
    end
  end
end
